starter · walkthrough
The render loop, annotated
starter.ts · 205 lines
1Click or hover a number to highlight its callout
pretext · @fit · freerange · vibescript
// ─────────────────────────────────────────────────────────────────────────────// starter.ts — render loop + pretext + @fit, minimal scene graph, one demo.//// Open /starter in the dev server. One headline and a strip of captions;// on every resize the page reflows with zero DOM measurement and every// caption is proved by @fit to fit its own column.// ─────────────────────────────────────────────────────────────────────────────1import { prepare, layout, type PreparedText } from '@chenglou/pretext'// ─── Types ───────────────────────────────────────────────────────────────────type Caption = { id: string text: string2 prepared: PreparedText}type Scene = { headlinePrepared: PreparedText captions: Caption[]}type LaidOutRow = { id: string x: number y: number width: number height: number}type LaidOut = { headlineHeight: number columnWidth: number rows: LaidOutRow[] totalHeight: number}type State = { scene: Scene viewportWidth: number3 events: { resize: boolean } animatedUntilTime: number | null}type DomCache = { headline: HTMLDivElement // cache lifetime: same as data captionLayer: HTMLDivElement // cache lifetime: same as data4 captions: Map<string, HTMLDivElement> // cache lifetime: on visibility changes}// ─── Fonts (keep in sync with CSS) ──────────────────────────────────────────5const HEADLINE_FONT = '500 60px "EB Garamond"'const HEADLINE_LINE_HEIGHT = 60 * 0.95const CAPTION_FONT = '400 13px Commissioner'const CAPTION_LINE_HEIGHT = 13 * 1.5const GAP = 24const PAGE_PAD = 56const MIN_COL = 220const MAX_COL = 320// ─── Initial scene (content lives as pure data) ──────────────────────────────function makeScene(): Scene { const headlineText = 'Measurement without reflow.' const captionsRaw = [ { id: 'c0', text: 'pretext measures text without a single DOM read…' }, { id: 'c1', text: 'vibescript runs one render loop with one state object…' }, { id: 'c2', text: 'freerange proves layout facts from source…' }, { id: 'c3', text: 'The page is never wrong for a frame…' }, ] return {6 headlinePrepared: prepare(headlineText, HEADLINE_FONT), captions: captionsRaw.map(c => ({ id: c.id, text: c.text, prepared: prepare(c.text, CAPTION_FONT), })), }}// ─── Layout (pure function of state → positions) ─────────────────────────────7/** @fit * given viewportWidth: 200..4000 * result.columnWidth: 220..320 * result.rows.length == scene.captions.length * nondecreasing(result.rows.y) * forall r in result.rows: r.x + r.width <= viewportWidth - PAGE_PAD */function layoutScene(scene: Scene, viewportWidth: number): LaidOut { const content = viewportWidth - PAGE_PAD * 2 // Pick column count so each column sits within [MIN_COL, MAX_COL] const cols = Math.max(1, Math.min(4, Math.floor((content + GAP) / (MIN_COL + GAP))))8 const columnWidth = Math.min(MAX_COL, (content - GAP * (cols - 1)) / cols) const { height: headlineHeight } = layout(scene.headlinePrepared, content, HEADLINE_LINE_HEIGHT) const rows: LaidOutRow[] = [] let rowY = headlineHeight + GAP * 2 let rowMaxH = 0 for (let i = 0; i < scene.captions.length; i++) { const col = i % cols if (col === 0 && i > 0) { rowY += rowMaxH + GAP; rowMaxH = 0 }9 const { height } = layout(scene.captions[i].prepared, columnWidth, CAPTION_LINE_HEIGHT) rows.push({ id: scene.captions[i].id, x: PAGE_PAD + col * (columnWidth + GAP), y: rowY, width: columnWidth, height, }) if (height > rowMaxH) rowMaxH = height } return { headlineHeight, columnWidth, rows, totalHeight: rowY + rowMaxH + PAGE_PAD }}// ─── State, DOM cache, render loop ───────────────────────────────────────────10const st: State = { scene: makeScene(), viewportWidth: document.documentElement.clientWidth, events: { resize: false }, animatedUntilTime: null,}const root = document.getElementById('root') as HTMLDivElementconst headline = document.createElement('div')headline.className = 'headline'headline.textContent = 'Measurement without reflow.'const captionLayer = document.createElement('div')captionLayer.className = 'caption-layer'root.appendChild(headline)root.appendChild(captionLayer)const domCache: DomCache = { headline, captionLayer, captions: new Map() }let scheduledRaf: number | null = nullfunction scheduleRender() {11 if (scheduledRaf != null) return scheduledRaf = requestAnimationFrame(function renderAndMaybeScheduleAnotherRender(now) { scheduledRaf = null if (render(now)) scheduleRender() })}// Event wiring — minimum logic, just capture & schedule12window.addEventListener('resize', () => { st.events.resize = true; scheduleRender() })function render(_now: number): boolean {13 // 1. DOM reads (batched) const viewportWidth = document.documentElement.clientWidth // 2. Handle inputs if (st.events.resize) st.viewportWidth = viewportWidth // 3. Layout (pure — no DOM) const laid = layoutScene(st.scene, st.viewportWidth) // 4. Animation tick — nothing to animate here; stub for the pattern const stillAnimating = false // 5. Commit state14 st.events.resize = false st.animatedUntilTime = stillAnimating ? st.animatedUntilTime : null // 6. DOM writes (batched, permanent nodes + JIT caption nodes) domCache.headline.style.width = `${laid.columnWidth * 2 + GAP}px` domCache.headline.style.transform = `translate(${PAGE_PAD}px, ${PAGE_PAD}px)` domCache.headline.style.height = `${laid.headlineHeight}px` // captions — JIT create on first visibility, never detach (all visible here) for (const row of laid.rows) { let node = domCache.captions.get(row.id) if (node == null) { node = document.createElement('div')15 node.className = 'caption' // static — set once node.textContent = st.scene.captions.find(c => c.id === row.id)!.text domCache.captions.set(row.id, node) domCache.captionLayer.appendChild(node) }16 node.style.transform = `translate(${row.x}px, ${row.y}px)` // dynamic node.style.width = `${row.width}px` node.style.height = `${row.height}px` } // evict any rows that disappeared (no-op today; scaffold for future) for (const [id, node] of domCache.captions) { if (!laid.rows.some(r => r.id === id)) { node.remove(); domCache.captions.delete(id) } } domCache.captionLayer.style.height = `${laid.totalHeight - PAGE_PAD}px` document.body.style.minHeight = `${laid.totalHeight}px` return stillAnimating}scheduleRender()
1Pretext · import

prepare once, layout many

prepare() does the expensive one-time work — segment text, measure with canvas, cache. layout() is cheap arithmetic over those cached widths.

The whole point: measurement never touches the DOM. No getBoundingClientRect, no forced reflow, no flash.

REF · docs/pretext/README.md
2Scene graph

The handle travels with the content

A PreparedText is the only field pretext touches on a caption. Carry it wherever the content goes; layout() will not need anything else.

3State

Events live inside state

Not in callbacks. Not in closures. A plain boolean the render loop reads and clears. That's what makes composition of events possible.

4DomCache

Every field has a lifetime

Each cached node gets a comment describing when you'd evict it. Update the comment before the code when the render pattern changes; the shape will follow.

5Footgun

Font string must match CSS

Pretext measures with canvas using this exact string. Out of sync with CSS = subtle mis-measurement, page flash, an afternoon of debugging. One source of truth, here.

6Timing

prepare at scene build time

Never call prepare() inside the render loop. Do it when content is born. If text later changes, rebuild that one handle.

7@fit · contract

The proof above the function

given is what callers may pass; the bare lines are facts freerange must prove from the body below.

No caption dropped (rows.length == captions.length). Rows flow downward (nondecreasing(y)). Nothing overflows the right margin. Break one, the build fails — long before a screenshot would.

REF · docs/freerange/README.md
8Invariant anchor

The clamp the proof watches

Math.min(MAX_COL, …) is what lets the prover discharge columnWidth: 220..320. Remove the clamp, lose the proof.

9Hot path

layout in the loop — safe

Pure arithmetic over prepared measurements. No DOM, no reflow. Call every frame as many times as you need.

10Initialization

One state. One cache.

Everything dynamic lives in st: content, viewport, events, animation clock. Single global state is a feature — serializable, cloneable, rewindable.

11Scheduler

One rAF in flight

A dozen events in a single frame collapse into one render. The guard if (scheduledRaf != null) return is the entire scheduler.

12Events

Flag and go

The handler stores one fact and schedules. No logic in the callback. Every decision lands in render(), where all of the state is visible together.

13Render body

The six phases, in order

reads → inputs → layout → animation tick → commit → writes. Phases never interleave. DOM reads never sit next to DOM writes.

14Critical

Forget this and you loop forever

Reset every ephemeral event flag in the commit phase. Skip it, and the next frame thinks the same resize is still fresh — a loop is born.

15DOM writes

Static once

className and textContent are set inside the create block, never touched again. Put anything constant here.

16DOM writes

Dynamic every frame

transform, width, height are written every frame from the layout result. One pass, in order, after layout has decided every number.